feat(rig): provision tg-ctl inbound daemon as a boot LaunchAgent#30
Conversation
Add the tg_ctl config block (validate + plan) and the pure, effect-free TgCtlPlan that renders the ai.hyperide.tg-ctl.plist LaunchAgent XML byte-exact to the working hand-created file (sort_keys=False preserves the insertion order so a re-apply is a true no-op). Default-on, per-machine (GLOBAL layer), macOS-only. Mirrors the tmux block's schema style. boot:null and label:null resolve to their defaults (not bool(None)=False / str(None)="None"). Reviewed via multi-model `review`; findings addressed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runner: _do_provision_tg_ctl writes the byte-exact plist, backs up a differing prior, ensures the log dir, tears down the stale predecessor (com.ultra.codex-tg-bot: bootout + timestamped backup + remove), and (re)loads via launchctl bootout/bootstrap in the gui/<uid> domain. A re-apply against the already-correct loaded plist is a skipped no-op. RIG_TG_CTL_DRY_RUN writes the plist but skips every live/destructive mutation (launchctl AND the stale teardown) so tests/smoke never touch the real launchd domain. Drift: _check_tg_ctl flags missing / divergent / written-but-not-loaded, a leftover plist when boot:false, and the stale predecessor (extra). CLI: GLOBAL status line shows installed / drifted / disabled / unsupported (off-darwin), resolved through the shared plan builder. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test_tg_ctl.py mirrors test_tmux.py: config validation, byte-exact plist render (incl. against the live machine plist when present, read-only), create/idempotent/conflict/dry-run states, stale-predecessor teardown, drift (missing/modified/extra/not-loaded), status states, and the boot:null / label:null / dry-run-no-stale-removal / off-darwin regressions. conftest neutralizes the default-on tg_ctl provisioner + drift check and stubs the gui-domain launchctl seams suite-wide (dedicated tests restore the real ones with their own HOME-isolated tmp dirs); no test ever touches the real ~/Library/LaunchAgents or runs real launchctl. smoke.sh gains a focused, HOME-isolated, RIG_TG_CTL_DRY_RUN tg-ctl leg and prefers `uv run --extra test pytest`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs/config-schema.md: the tg_ctl section (keys, defaults, the byte-exact no-op contract, gui-domain (re)load, stale-predecessor teardown, drift, the RIG_TG_CTL_DRY_RUN seam, and the enabled:false vs boot:false distinction) + the validation paragraph. AGENTS.md: refine the "never mutate a LIVE service" rule — the stateless background daemons (models cron, tg_ctl) are the documented (re)load exceptions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ixture #27's clean-sample leg enumerates every default-ON category and disables it to assert zero false drift. tg_ctl (added by this PR) is default-ON, so its provision_tg_ctl action made the clean sample report drift (exit 3). Mirror how #29 added 'gitignore: {enabled: false}' and disable tg_ctl too.
f0a4306 to
9eb0531
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9eb0531761
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "tg-ctl boot LaunchAgent differs from the configured plist") | ||
| ) | ||
| return | ||
| if not _launchctl_gui_loaded(plan.boot_label): |
There was a problem hiding this comment.
Honor tg_ctl dry-run when checking launchd
When RIG_TG_CTL_DRY_RUN=1 is set, _do_provision_tg_ctl intentionally writes the plist but skips launchctl bootstrap; this status check still queries the real launchd domain and reports the freshly written agent as “installed but not loaded.” On macOS, a dry-run-isolated rig init/apply (including smoke/CI runs that set this env var to avoid touching launchd) can therefore never be followed by a clean rig status unless it performs the live mutation the flag is meant to prevent. Gate this loaded-state check under the same dry-run condition as the provisioner.
Useful? React with 👍 / 👎.
What
rig init/rig applynow provision the tg-ctl inbound control daemon (tg-cli'slong-poll / inject-into-tmux / voice→text daemon, run as
tg-ctl run) as a macOS bootLaunchAgent so it auto-starts at login/boot — exactly like rig already does for the tmux
boot service. On a clean machine,
rig initalone sets it up (the block is default-ON).Design — mirrors tmux-boot
The whole pipeline follows the existing
tmuxpattern (one source of truth shared by plan,apply, drift):
riglib/tg_ctl.py— a pure, stdlib-only, effect-freeTgCtlPlan(analog ofriglib.tmux) that renders~/Library/LaunchAgents/ai.hyperide.tg-ctl.plist. It usesplistlib.dumps(..., sort_keys=False)so the key order matches the hand-created, workinglive plist byte-for-byte —
rig applyagainst the live file is a true no-op (skipped),never a spurious rewrite. Paths are derived from
$HOME; bun is discovered (which bun→~/.bun/bin/bunfallback); config dir honors$TG_CTL_CONFIG_DIR(default~/.config/tg-cli).runner._do_provision_tg_ctl— render → back up a differing prior (timestamped) → write →(re)load. Unlike tmux-boot (which only writes the plist), this agent is (re)loaded via
launchctl bootout/bootstrapin the per-usergui/<uid>domain, so a cleanrig initstarts the daemon without a reboot. Reuses
_timestamped_backup_pathand thefsutilconflict engine.
drift._check_tg_ctl— flags missing / divergent / written-but-not-loaded, a leftoverplist when
boot:false, and the stale predecessor (extra).rig statussurfaces it in theGLOBAL section (installed / drifted / disabled / unsupported-off-darwin).
Config block (GLOBAL layer)
Per-machine concern → belongs in
~/.config/rig/config.yaml(likeharness/tmux/git_hooks),NOT a committed repo
rig.yaml. Default ON;enabled/bootdefault true. Follows thetmux:schema style (fail-closed validation). See
docs/config-schema.md#tg_ctl.Stale-predecessor removal
If
~/Library/LaunchAgents/com.ultra.codex-tg-bot.plist(the dead predecessor) exists,rig applyboots it out, backs it up (timestamped), and removes it.Idempotency — proven against the live plist
A real
rig apply(no dry-run) against this machine's live, loadedai.hyperide.tg-ctl.plistis a
skippedno-op: the plist sha is unchanged and nobootstrap/bootoutfires (thebyte-identical-AND-loaded early-return). Verified directly.
Tests / smoke
510 passed(uv run --extra test pytest tests/).0(bash tests/smoke.sh), including a new focused, HOME-isolated,RIG_TG_CTL_DRY_RUNtg-ctl leg.~/Library/LaunchAgentsor runs reallaunchctl— conftest neutralizes the default-on provisioner + drift check and stubs thegui-domain launchctl seams suite-wide; dedicated tests restore the real ones with their own
temp HOME + a
RIG_TG_CTL_DRY_RUN-style dry-run seam. The real tg-ctl plist sha was unchangedby the entire test run.
Review
Ran multi-model
reviewon the diff; addressed its findings — fixed a dry-run disk-mutationleak in the stale teardown,
boot:null/label:nulldefaulting, off-darwin status wording,collapsed the launchctl bootout/bootstrap helpers, and added regression tests for each.
Do NOT merge — draft.
🤖 Generated with Claude Code